Conversation
Why these changes are being introduced: `ThesisController#deleted_file_list` calls `.blob` on a potentially nil Active Storage attachment. The processing form can trigger a race condition when a background process deletes a file that is still visible on the form. If a user attempts to delete that file and the attachment no longer exists, the app throws a 422 error. Relevant ticket(s): - [USE-710](https://mitlibraries.atlassian.net/browse/ETD-710) How this addresses that need: This adds a guard clause to check for the presence of the attachment before calling `.blob`. Side effects of this change: None.
There was a problem hiding this comment.
🟡 Changes recommended
Add coverage verifying stale attachment submissions complete without a 422/error.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Guards thesis processing against deleted Active Storage attachments.
Changes:
- Checks attachment existence before accessing its blob.
- Skips stale attachment entries safely.
File summaries
| File | Description |
|---|---|
app/controllers/thesis_controller.rb |
Adds a nil attachment guard in deleted_file_list. |
The stale-attachment regression path lacks controller test coverage and should be tested.
Review details
Suppressed comments (1)
app/controllers/thesis_controller.rb:182
- This guard only skips building the flash-message entry; the same stale
idremains inthesis_paramsand is passed tothesis.updateat line 124. BecauseThesisenablesaccepts_nested_attributes_for :files_attachments, Rails can still raiseActiveRecord::RecordNotFoundwhile applying_destroyfor an attachment that disappeared, so the reported 422 race is not fully prevented. Remove or ignore missing attachment entries before the update (and add a regression test for the stale id).
next unless attachment
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| attachment = ActiveStorage::Attachment.find_by(id: file['id']) | ||
| next unless attachment |
There was a problem hiding this comment.
🟡 Changes recommended
The update path remains vulnerable to stale attachment races and incomplete truthy-value handling.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
app/controllers/thesis_controller.rb:128
- This filter only recognizes the HTML string value
'1'.accepts_nested_attributes_fortreats other truthy forms such as1/trueas_destroy, so a stale attachment submitted with one of those values is not removed here andthesis.updatecan still raiseActiveRecord::RecordNotFound. Use the same boolean casting Rails uses for nested attributes (or normalize the value) before checking the missing attachment.
marked_for_delete = attrs['_destroy'] == '1'
attachment_id = attrs['id']
missing_attachment = attachment_id.present? && !ActiveStorage::Attachment.exists?(attachment_id)
test/controllers/thesis_controller_test.rb:1015
- This regression test removes the row before the request, so the new
exists?filter deletes the submitted attributes beforedeleted_file_listruns. As a result, the test still passes if the newfind_by/nextguard at lines 193-195 is removed and does not actually exercise the nil-attachment path it claims to cover. Please add an assertion or setup that specifically makesdeleted_file_listreceive a missing attachment (while separately covering the nested-update stale-row behavior).
# Simulate the race condition: Delete the attachment from the database
# (This could happen if another process deletes it between form open and submit)
ActiveStorage::Attachment.find(attachment_id).delete
# Attempt to update the thesis with the deleted attachment marked for deletion
# This previously would crash with "undefined method 'blob' for nil:NilClass"
patch "/thesis/#{thesis.id}/process",
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
The retry logic still has a race window, and the regression test does not exercise the intended race path.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
app/controllers/thesis_controller.rb:138
- The retry still leaves the same race window open: after the second
drop_stale_deleted_attachment_rows!check, another process can delete the attachment beforethesis.update, and becauseretried_for_missing_attachmentis already true, thatRecordNotFoundis re-raised and the request still returns the 422 this change is intended to prevent. Consider handling the stale row atomically during the update or retrying/filtering until the submitted attachment rows are stable (with a bounded retry policy).
rescue ActiveRecord::RecordNotFound => e
raise unless missing_deleted_attachment_race?(e) && !retried_for_missing_attachment
retried_for_missing_attachment = true
drop_stale_deleted_attachment_rows!
retry
test/controllers/thesis_controller_test.rb:1011
- This setup deletes the attachment before the request, so
drop_stale_deleted_attachment_rows!removes the submitted row at lines 123–124 anddeleted_file_listnever receives a missing attachment. Consequently, the regression test would still pass if the newfind_byguard were removed; please arrange the deletion between the stale-row check anddeleted_file_list(or directly exercise that method) so the nil-attachment path is actually covered.
# Simulate the race condition: Delete the attachment from the database
# (This could happen if another process deletes it between form open and submit)
ActiveStorage::Attachment.find(attachment_id).delete
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved test route failures and attachment-handling cases remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
app/controllers/thesis_controller.rb:199
- The new
next unless attachmentbranch is not exercised by either regression test: the first test removes the row beforedeleted_file_listruns, while the second removes it only duringupdateafter this method has already completed. Please add a deterministic test that deletes the attachment between the stale-row check and this lookup (or otherwise makesfind_byreturn nil) so the actual nil-guard behavior is verified.
attachment = ActiveStorage::Attachment.find_by(id: file['id'])
next unless attachment
app/controllers/thesis_controller.rb:216
- This stale-row filter only recognizes the string
'1', but nested-attribute requests can carry_destroyas an integer (1is already used by the existing controller tests). For a missing attachment with that value, the row is not pruned, the first update raises, and the retry prunes nothing and raises again, so the request still fails. Use Rails' boolean casting (or normalize the value) here, consistently with nested-attribute semantics.
params[:thesis][:files_attachments_attributes].delete_if do |_k, attrs|
marked_for_delete = attrs['_destroy'] == '1'
attachment_id = attrs['id']
missing_attachment = attachment_id.present? && !ActiveStorage::Attachment.exists?(attachment_id)
test/controllers/thesis_controller_test.rb:1106
- This named route requires the thesis
:id(seeconfig/routes.rb:43), so callingthesis_process_pathwithout an argument raisesActionController::UrlGenerationErrorwhile evaluating the assertion. Pass the thesis used by this test so the regression test can run.
assert_redirected_to thesis_process_path
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Regression tests use incorrect attachment IDs and omit the required route parameter.
Review details
Suppressed comments (3)
test/controllers/thesis_controller_test.rb:1106
- This route requires the thesis
:idparameter (config/routes.rb:43), so callingthesis_process_pathwithout an argument raisesActionController::UrlGenerationErrorwhile evaluating the assertion. Pass the thesis, as the preceding regression test does, so this test can reach its response assertions.
assert_redirected_to thesis_process_path
test/controllers/thesis_controller_test.rb:1007
has_many_attached :filesreturns blobs, sothesis.files.first.idis the blob ID rather than theActiveStorage::AttachmentID expected byfiles_attachments_attributes. The test can therefore delete a different attachment (or fail to find one), meaning it does not reliably exercise the stale-attachment path. Usethesis.files_attachments.first.id(or capture the attachment returned by the association) here.
# Get the attachment ID to mark for deletion
attachment_id = thesis.files.first.id
test/controllers/thesis_controller_test.rb:1059
- The race test has the same blob/attachment ID mismatch:
thesis.files.first.idis a blob ID, while the nested attributes andActiveStorage::Attachment.find_bycall require the thesis attachment ID. As a result, the wrapper may delete a transfer attachment or no record, so the asserted retry path is not deterministic. Capturethesis.files_attachments.first.idinstead.
attach_files_to_records(transfer, thesis)
attachment_id = thesis.files.first.id
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Add deterministic test coverage for the nil-attachment failure path.
Review details
Suppressed comments (1)
app/controllers/thesis_controller.rb:200
- The new nil guard is not exercised by either regression test: the first test deletes the row before
drop_stale_deleted_attachment_rows!runs, while the second deletes it insideupdate, afterdeleted_file_listhas already completed. Add a deterministic test that lets the initial stale-row check observe the attachment and then removes it immediately beforedeleted_file_listcallsfind_by, so this specific failure mode is covered.
attachment = ActiveStorage::Attachment.find_by(id: file['id'])
next unless attachment
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Given how much LLM ping-pong was involved here, I agree with Copilot's last assessment. I'm kind of annoyed by the in-test monkey-patching, but I couldn't figure another way to test the conditions that Copilot flagged. |
JPrevost
left a comment
There was a problem hiding this comment.
Weird and neat problem.
Thanks for cleaning it up.
| begin | ||
| updated = thesis.update(thesis_params) | ||
| rescue ActiveRecord::RecordNotFound => e | ||
| raise unless missing_deleted_attachment_race?(e) && !retried_for_missing_attachment |
There was a problem hiding this comment.
So this gives us one retry correct? This isn't a loop, it's just one failure, one retry, and a second failure is an exception I believe.
There was a problem hiding this comment.
Yeah. I figured if the problem is persistent, it's likely caused by something else that we should know about.
Why these changes are being introduced:
ThesisController#deleted_file_listcalls.blobon a potentially nil Active Storage attachment.The processing form can trigger a race condition
when a background process deletes a file that is
still visible on the form. If a user attempts to
delete that file and the attachment no longer
exists, the app throws a 422 error.
Relevant ticket(s):
How this addresses that need:
This adds a guard clause to check for the presence of the attachment before calling
.blob.Side effects of this change:
None.
Developer
Accessibility
New ENV
Approval beyond code review
Additional context needed to review
E.g., if the PR includes updated dependencies and/or data
migration, or how to confirm the feature is working.
Code Reviewer
Code
added technical debt.
Documentation
(not just this pull request message).
Testing